Skip to content

Support Share to Zulip on Android #1774

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 13, 2025
Merged

Conversation

rajveermalviya
Copy link
Member

@rajveermalviya rajveermalviya commented Jul 31, 2025

Screenshots

Channels (Light) Channels (Dark) Direct Messages (Light) Direct Messages (Dark) Long Tab Name Error dialog
image image image image image image

Screen recordings

Single File Multiple Files Text Text + File
Channels
channels-single-file.mp4
channels-multiple-files.mp4
channels-text.mp4
channels-text+image.mp4
DMs
dms-single-file.mp4
new-dms-multiple-file.mp4
dms-text.mp4
new-dms-text+image.mp4

Fixes: #53

@gnprice
Copy link
Member

gnprice commented Jul 31, 2025

Great, glad to see this!

I've watched the three videos. The UX generally looks good. Comments there:

  • When choosing a channel, it looks like focus goes to the main compose input rather than the topic input. The spec called for the latter. Can you easily make it do that instead? (If not, that can be a follow-up issue; not a blocker for shipping this.)
  • This version uses the first account in the list. That's fine for an initial version. Let's file a follow-up issue (in M6, the milestone that starts next week) for allowing the user to select an account from the list if there are several of them, as described in the spec.
  • The "channels" and "direct messages" tabs are labeled by icons. I think the usual convention for top tabs like these would be to use text labels instead.
  • Can you post a screenshot and a video demonstrating using the "New DM" button? Because that does some navigation of its own, I'm curious to see how it interacts.

/cc @alya also for her feedback on the UX.

I haven't yet looked much at the code (since this is a draft), just briefly skimmed. It looks like one key thing still for you to do is to split the changes out into distinct commits, which will help with reading them.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and here's comments from a high-level scan of the code. Excited to see this feature!


class MainActivity: FlutterActivity() {
class MainActivity : FlutterActivity() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a fair amount of code added here. Was there a particular place (or handful of places) you looked for working out what this code should look like? That'll be helpful to point to in commit messages.

For example, specific files in the legacy app; or places in the Flutter tree; or Android documentation; or other sources.

bytes = bytes)
}

private fun handleSend(intent: Intent) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this method (and its helper getIntentSharedFileInfo) move onto a class other than MainActivity? Perhaps on AndroidIntentEventListener, since that seems closely related.

That would help for making it explicit what information or methods they're using, if any, from FlutterActivity or its base classes.

Even if the methods have to be passed this as a FlutterActivity (or some supertype of that), I think the explicitness of accessing FlutterActivity members on that argument would be helpful.

@@ -161,6 +165,40 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
UpgradeWelcomeDialog.maybeShow();

// Move to a AndroidShareService or similar.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this would be a helpful bit of code organization.

I think a good home for that would be in lib/widgets/share.dart . It's fundamentally closely tied to the SharePage widget.

Comment on lines 170 to 171
ZulipBinding.instance.androidIntentEvents.forEach((androidIntentEvent) async {
assert(debugLog('androidIntentEvent.action: ${androidIntentEvent.action}'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also make this callback a named function. Can be a private static method on the relevant class.

Comment on lines 2037 to 2041
@override
void insertText(String newText) {
final contentController = controller.content;
contentController.value = contentController.value.replaced(contentController.insertionIndex(), '$newText\n');
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about inlining this at the call site? The caller can use the controller getter, which is already exposed in the ComposeBoxState interface.

(That's effectively what we do for things like quote-and-reply.)

Comment on lines 185 to 186
Navigator.pop(context); // TODO why doesn't this work?
onSelect!(narrow);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this TODO is just a note to yourself or if you'd like input on it — if the latter, I'd be curious to hear what behavior you're seeing, and what behavior you expected 🙂

Comment on lines 142 to 156
Navigator.push(context,
MessageListPage.buildRoute(context: context, narrow: narrow));
if (onSelect != null) {
onSelect!(narrow);
} else {
Navigator.push(context,
MessageListPage.buildRoute(context: context,
narrow: narrow));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment as for the onSelect on _NewDmHeader above.

Comment on lines 213 to 281
onLongPress: () => showChannelActionSheet(context, channelId: subscription.streamId),
onLongPress: !disableChannelActionSheet
? () => showChannelActionSheet(context, channelId: subscription.streamId)
: null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. What goes wrong without this change, if the user does try to open a channel action sheet?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue was that user could open the Topic List page from that action sheet, and when selected a topic from that topic list, the compose box of the topic message list wouldn't be populated with the content.

See discussion: #mobile-design > share to Zulip @ 💬

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest revision now replaces this flag with one to disable just the topic list button from the channel action sheet, rather than disabling whole action sheet.

Comment on lines +15 to +16
// TODO separate out API calls for resolving file name, getting mimetype, getting bytes?
class IntentSharedFile {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds potentially useful but definitely a follow-up task, if this version already seems to work correctly 🙂

Comment on lines 53 to 147
if (filesToUpload != null) {
await composeBoxState.uploadFiles(filesToUpload!);
} else if (sharedText != null) {
composeBoxState.insertText(sharedText!);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need the else? It seems like this could work just fine with both if blocks acting independently.

Probably then put the text before the uploads.

I'm imagining that there might be sharing sources that include both some text and some images or other files. I'm not sure if the platform APIs support that; but it's definitely a natural thing for people to want to do, so perhaps they might. (After all, even when the thing shared from the other app is purely an image, people often/usually want to add to add some text before sending it, in Zulip as well as in many other apps.)

@alya
Copy link
Collaborator

alya commented Jul 31, 2025

Yeah, I also wanted to note that it would be more clear to label the tabs as "Channels" and "Direct messages" (or "DMs" if that fits better). Looks promising!

@rajveermalviya rajveermalviya force-pushed the pr-share-to-zulip branch 7 times, most recently from 78f40a3 to 0c75b06 Compare August 1, 2025 05:48
@rajveermalviya rajveermalviya marked this pull request as ready for review August 1, 2025 06:06
@rajveermalviya
Copy link
Member Author

Thanks for the review @gnprice! Pushed an update and also updated the screenshots and recording, PTAL.

@rajveermalviya
Copy link
Member Author

When choosing a channel, it looks like focus goes to the main compose input rather than the topic input. …

Current revision tries to handle this.

This version uses the first account in the list. That's fine for an initial version. Let's file a follow-up issue (in M6, the milestone that starts next week) for allowing the user to select an account from the list if there are several of them, as described in the spec.

Filed #1779.

The "channels" and "direct messages" tabs are labeled by icons. I think the usual convention for top tabs like these would be to use text labels instead.

Fixed.

Can you post a screenshot and a video demonstrating using the "New DM" button? …

Added the video in the description, specifically it's the one labelled as "Text (New DM)".

@alya
Copy link
Collaborator

alya commented Aug 1, 2025

In your screenshots, why does the list of channels start with "Unpinned"? I would expect that to appear only if there are pinned channels above.

@rajveermalviya
Copy link
Member Author

Looks like we show the "Unpinned" header unconditionally on Subscription list page:

if (unpinned.isNotEmpty) ...[
_SubscriptionListHeader(label: zulipLocalizations.unpinnedSubscriptionsLabel),
_SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned),
],

Screenshot image

@gnprice
Copy link
Member

gnprice commented Aug 1, 2025

Thanks for the revision!

From the updated videos, the one UX comment I have is: after choosing a stream, it looks like focus starts out in the content input, and gets moved to the topic input only after the upload completes. Can we instead move it to the topic input immediately? That way the user can be choosing the topic while the upload is happening.

Comment on lines 144 to 150
// Then upload the files and populate the compose box with their links.
if (sharedFiles != null) {
await composeBoxState.uploadFiles(sharedFiles!);
}

// Try to focus on the topic compose box if there is one,
// else focus on content compose box, if not already focused.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think specifically that means this latter step should happen before doing an await on the former step.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Fixed in current revision, and also updated videos, PTAL.

@rajveermalviya rajveermalviya added maintainer review PR ready for review by Zulip maintainers integration review Added by maintainers when PR may be ready for integration labels Aug 1, 2025
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is exciting! I didn't use the legacy app's implementation at all in my regular Zulip use, but from what I remember, this feels much smoother. Comments below.

@@ -928,7 +928,7 @@ class FileToUpload {
Future<void> _uploadFiles({
required BuildContext context,
required ComposeContentController contentController,
required FocusNode contentFocusNode,
required FocusNode? contentFocusNode,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compose [nfc]: Expose uploadFiles on ComposeBoxState

This will be used soon to allow uploading files directly
via using `MessageListPageState`.

I don't love this change to _uploadFiles's interface: on the surface, it looks like contentController and contentFocusNode disagree about whether the content input always or only sometimes exists.

From the later commit, I can infer that this change isn't actually about the presence/absence of the content input; it's about a detail of how the function interacts with the input. Instead of doing this, how about making a flag that's specific to that behavior detail, like bool shouldRequestFocus = true?

Relatedly (but I wouldn't block on it), _uploadFiles could get its contentController and contentFocusNode from the ComposeBoxController, couldn't it? That could be a single param to replace those two params. Or it could even come from the context param, via ComposeBoxInheritedWidget.of, with some prep work to add a field for it to ComposeBoxInheritedWidget.

@@ -1869,6 +1869,24 @@ abstract class ComposeBoxState extends State<ComposeBox> {

/// Switch the compose box back to regular non-edit mode, with no content.
void endEditInteraction();

/// Uploads the provided files, populating the compose box with their links.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: s/compose box/content input/

Comment on lines 1154 to 1261
"errorSharingAccountNotLoggedIn": "There was no account logged in. Please login to an account and try again.",
"@errorSharingAccountNotLoggedIn": {
"description": "Error title when sharing content received from other apps fails, when there was no account logged in"
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nits:

  • "There is" instead of "There was", right?
  • "log in" for the verb, as here; "login" for the noun

Comment on lines 130 to 140
// Try to focus on the topic compose box if there is one,
// else focus on content compose box, if not already focused.
if (composeBoxController is StreamComposeBoxController) {
if (!composeBoxController.topicFocusNode.hasFocus) {
composeBoxController.topicFocusNode.requestFocus();
}
} else {
if (!composeBoxController.contentFocusNode.hasFocus) {
composeBoxController.contentFocusNode.requestFocus();
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about composeBoxController.requestFocusIfUnfocused() for this? 🙂

});

final bool disableChannelActionSheet;
final bool hideChannelsIfUserCantPost;
final OnChannelSelectCallback? onChannelSelect;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding TODO(#412) add onTopicSelect? In share-to-Zulip, that will probably be more useful to people, when we get around to it :) especially if it includes a "new topic" button as I've just suggested on that issue: #412 (comment)

…Actually, trying this out now, I think we might (eventually) want to force the user to choose a topic or start a new topic. When I land on the interleaved channel view with an empty topic input, the first thing I tried was to tap a topic recipient header, which brought me to a new page (the topic page) without the content I wanted to share.

import android.net.Uri
import android.provider.OpenableColumns

class IntentListener : AndroidIntentEventsStreamHandler() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: How about:

  • naming this IntentEventListener or even AndroidIntentEventListener, corresponding to AndroidIntentsEventChannelApi.androidIntentEvents()

  • and below, rename onAndroidIntentEvent to just onEvent?

This way feels more streamlined in loading the "event" abstraction into the reader's brain, and also making it clear that "android intent event" is the only kind of event this class is responsible for.

Comment on lines 31 to 78
// App can receive both an EXTRA_TEXT and EXTRA_STREAM (files)
// in the same intent. And the documentation states that EXTRA_TEXT
// should always be "text/plain", and it also states that it can be
// a mime type of the file/s in EXTRA_STREAM, but while testing
// Chrome seems to always set this as the source URL for the shared
// image, and Firefox sets this to null when sharing an image.
// So, we use this string as-is, assuming the documented later part
// isn't observed in the wild.
//
// See: https://developer.android.com/reference/android/content/Intent#ACTION_SEND
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)

val event = when (intent.action) {
Intent.ACTION_SEND -> {
if ("text/plain" == intent.type) {
AndroidIntentSendEvent(
action = Intent.ACTION_SEND,
extraText = extraText
)
} else {
// TODO(android-sdk-33) Remove the use of deprecated API.
@Suppress("DEPRECATION") val url = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
?: throw Exception("Could not extract URL from File Intent")
val sharedFile = getIntentSharedFile(context, url)
AndroidIntentSendEvent(
action = Intent.ACTION_SEND,
extraText = extraText,
extraStream = listOf(sharedFile)
)
}
}

Intent.ACTION_SEND_MULTIPLE -> {
// TODO(android-sdk-33) Remove the use of deprecated API.
@Suppress("DEPRECATION") val urls =
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?: throw Exception("Could not extract URLs from File Intent")
val extraStream = mutableListOf<IntentSharedFile>()
for (url in urls) {
val sharedFile = getIntentSharedFile(context, url)
extraStream.add(sharedFile)
}
AndroidIntentSendEvent(
action = Intent.ACTION_SEND_MULTIPLE,
extraText = extraText,
extraStream = extraStream
)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this simplifies to the following:

  • Comment that the doc is wrong to imply that EXTRA_TEXT and EXTRA_STREAM can't both be present ("get*Extra can have either a EXTRA_TEXT or EXTRA_STREAM field"); this happens empirically
  • First, check for EXTRA_STREAM. If it's present, include the file(s).
  • Add EXTRA_TEXT, too, if that's present. No mime-type check (the current revision defeats sharing a plain-text file).
  • Maybe a comment that EXTRA_TEXT might be text that the user doesn't want, like the URL of an image on the web. But, shrug, we're including it anyway because that's what Chrome/etc. put in the intent, and it's the other app's responsibility to choose what it intends to be shared.

Is that right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Also, sent a new revision further simplifying and eliminating the if ("text/plain" == intent.type) { branch, PTAL.

Comment on lines 78 to 99
sharedFiles: intentSendEvent.extraStream?.map((sharedFile) {
return FileToUpload(
content: Stream.value(sharedFile.bytes),
length: sharedFile.bytes.length,
filename: sharedFile.name,
mimeType: sharedFile.mimeType);
}),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to call lookupMimeType when sharedFile.mimeType is null? (See example in compose_box.dart)

If that's complicated, we could file a followup for it.

Comment on lines 187 to 193
SubscriptionListPageBody(
disableChannelActionSheet: true,
hideChannelsIfUserCantPost: true,
onChannelSelect: _handleNarrowSelect),
RecentDmConversationsPageBody(
hideDmsIfUserCantPost: true,
onDmSelect: _handleNarrowSelect),
Copy link
Collaborator

@chrisbobbe chrisbobbe Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice to do a tearoff for _handleNarrowSelect, i.e. onChannelSelect: _handleNarrowSelect vs. onChannelSelect: (…) => _handleNarrowSelect(…). But is that the only reason that _handleNarrowSelect is getting passed a BuildContext from much deeper than this in the widget tree?

If _handleNarrowSelect adds some async work up front (which, true, it doesn't have right now), then we'll need an isMounted check after the await…and then I think we need to investigate if something like a channel-rename can cause the context to unmount, if the context is on a per-channel widget.

Simpler to just use this widget's context, if we can:

onChannelSelect: (narrow) => _handleNarrowSelect(narrow, context)

right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I had added the BuildContext because in the initial draft I was observing a bug that pressing back from the message list page, when opened from new dm conversation action sheet. I would be led to the share page again instead of the home page. Even with the extra Navigator.pop for the action sheet. And I had thought that adding the BuildContext and passing that for Navigator was what fixed it.

But for some reason after I've removed this BuildContext now, I do not observe that bug again, so looks like it was something else in the initial draft that was not working.

Anyway removed the BuildContext in the latest revision.

Tab(text: zulipLocalizations.channelsPageTitle),
Tab(text: zulipLocalizations.recentDmConversationsPageTitle),
])),
body: SafeArea(
Copy link
Collaborator

@chrisbobbe chrisbobbe Aug 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls for a

          // Don't pad the bottom here; we want the list content to do that.
          bottom: false,

. And if we're settled on repurposing SubscriptionListPageBody and RecentDmConversationsPageBody1, then a few more changes are needed:

  • Revert the changes to those widgets in 742320c
  • Move the "New DM" button up by the height of the bottom inset (the new-DM button landed after we added the bottom nav)
  • Find a factoring or comment to explain why the FooPageBody widgets aren't uniform in these details

Footnotes

  1. I wonder if this is a case of "prefer duplication over the wrong abstraction": https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the "hide channels/conversations where the user can't post" flags, and the "don't offer channel action sheet" flag, are definitely signs that these existing "page body" widgets are probably not the abstractions we'd ideally want to be sharing, and that the ideal way to factor this would probably involve two new widgets and a certain amount of duplication.

OTOH there's quite a bit of logic in both of these pages, and the bulk of it I wouldn't want to duplicate: we really do mean for the the bulk of that behavior and UI to be uniform between this sharing context and the home-tabs context, and would risk divergence if we started having two copies of it. I think getting to the point where we could duplicate the right bits without duplicating all the rest would require some nontrivial refactoring.

So I think repurposing those widgets is the right solution for the present.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the SafeArea: the page-body widgets are already taking care of that, right? As Chris says, those need bottom: false added. Then they'll continue to take care of the left and right insets; and the top inset is handled by the app bar.

So I think this SafeArea can be omitted. Is there something that goes wrong if it's left out?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the SafeArea: the page-body widgets are already taking care of that, right?

Taking care of what specifically? 🙂

In this revision, in the emulator I'm running, the middle of the list looks like this:

image

No change if I remove this SafeArea.

It should look like this, in the middle of the list:

image

And this, at the bottom of the list:

image

But it doesn't because 742320c stopped delegating to list content the job of detecting and padding the bottom inset, and instead relied on the assumption that that's handled externally, by the bottom tabs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. So the page-body widgets are already taking care of the left and right insets (and, wrongly for this purpose, a bottom inset). There's nothing for a SafeArea here to do.

And those other SafeArea calls need to be adjusted to not try to handle a bottom inset.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sent this as c9391df, PTAL.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Responded there—for some reason, as Greg pointed out, my comment didn't end up showing on the PR thread: c9391df#r163401974

@rajveermalviya
Copy link
Member Author

Thanks for the review @chrisbobbe. Pushed an update, PTAL.

chrisbobbe referenced this pull request Aug 4, 2025
This reverts part of 742320c for RecentDmConversationsPageBody
and SubscriptionListPageBody, as these widgets will soon be used
outside the context of home page, specifically for the upcoming
share page.

The other *PageBody widgets, currently only InboxPageBody, don't
need these as they aren't used outside the context of the home
page.
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Small comments below, and this continues to work in my manual testing. I guess we'll want a TODO and an issue to write tests, if we're aiming to ship this before tests are written? (Like #1620 etc.)

Comment on lines 32 to 42
// App can receive both an EXTRA_TEXT and EXTRA_STREAM (files)
// in the same intent. And the documentation states that EXTRA_TEXT
// should always be "text/plain", and it also states that it can be
// a mime type of the file/s in EXTRA_STREAM, but while testing
// Chrome seems to always set this as the source URL for the shared
// image, and Firefox sets this to null when sharing an image.
// So, we use this string as-is, assuming the documented later part
// isn't observed in the wild.
//
// See: https://developer.android.com/reference/android/content/Intent#ACTION_SEND
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up from #1774 (comment) , this comment is still confusing to me:

  • It makes it sound like the doc implies that the EXTRA_TEXT string itself represents a mime type, but the doc doesn't imply that. (It mentions getType giving a mime type.)
  • About the possibility that EXTRA_TEXT and EXTRA_STREAM are both present:
    • It should make clear why we believe this behavior is possible: because we've seen it. (IOW it's not from reading code or seeing a StackOverflow thread or something)
    • It should make clear why that's interesting/important: because it contradicts the Android doc, and we've created our own interpretation of what it means.
    • It would be helpful to make clear what interpretation we're relying on: that files and text are both included in the share-to intent, not just one or the other.

How about:

    assert(
      intentAction == Intent.ACTION_SEND
          || intentAction == Intent.ACTION_SEND_MULTIPLE
    )

    // EXTRA_TEXT and EXTRA_STREAM are the text and file components of the content, respectively.
    // The ACTION_SEND{,_MULTIPLE} docs say "either" / "or" will be present:
    //   https://developer.android.com/reference/android/content/Intent#ACTION_SEND
    // But empirically both can be present, commonly, so we accept that form, interpreting it as
    // an intent to share both kinds of data.
    //
    // Empirically, sometimes EXTRA_TEXT isn't something we think needs to be shared, like the URL 
    // of a file that's present in EXTRA_STREAM…but we shrug and include it anyway because we don't
    // want to second-guess other apps' decisions about what to include; it's their responsibility.

    val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)
    val extraStream = when (intentAction) {
      Intent.ACTION_SEND -> {
        // etc.

final bool disableChannelActionSheet;
final bool hideChannelsIfUserCantPost;
final OnChannelSelectCallback? onChannelSelect;
// TODO(#412) add onTopicSelect
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this comment, could we mention the idea of forcing the user to choose a topic or start a new topic? (Or maybe somewhere more specific to the share-to code.) As I mentioned in https://github.com/zulip/zulip-flutter/pull/1774/files#r2248956083 , the current behavior can be pretty frustrating:

When I land on the interleaved channel view with an empty topic input, the first thing I tried was to tap a topic recipient header, which brought me to a new page (the topic page) without the content I wanted to share.

Comment on lines 1876 to 1878
/// If any of the file is larger than maximum file size allowed by the
/// server, an error dialog is shown mentioning their names and actual
/// file sizes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// If any of the file is larger than maximum file size allowed by the
/// server, an error dialog is shown mentioning their names and actual
/// file sizes.
/// If any of the files are larger than maximum file size allowed by the
/// server, an error dialog is shown mentioning their names and actual
/// file sizes.

}

if (extraText == null && extraStream == null) {
throw Exception("Got unexpected ACTION_SEND* intent, without both EXTRA_TEXT and EXTRA_STREAM")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw Exception("Got unexpected ACTION_SEND* intent, without both EXTRA_TEXT and EXTRA_STREAM")
throw Exception("Got unexpected ACTION_SEND* intent, with neither EXTRA_TEXT nor EXTRA_STREAM")

(It's OK if the intent doesn't have both things, but not OK if it has zero of them.)

@rajveermalviya
Copy link
Member Author

@chrisbobbe said:

Bump on #1774 (comment)

Interesting. What goes wrong without this change, if the user does try to open a channel action sheet?

What if we just let the user open the channel action sheet? …

The reason for disabling the channel action sheet, was that user could open the topic list from there and if tapped on a topic, the compose box in the topic narrow message list wouldn't be populated with the shared content. See discussion: #mobile-design > share to Zulip @ 💬.

The latest revision now hides the topic list button from the channel action sheet (when on share page), instead of disabling the whole channel action sheet.

@rajveermalviya
Copy link
Member Author

Thanks for the reviews @chrisbobbe, @gnprice! Pushed an update, PTAL.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rajveermalviya for building this, and @chrisbobbe for the previous reviews!

Here's a full review of all but the last/main commit (so the first 10 commits). Reading that last/main commit now.

Comment on lines 175 to 176
GlobalKey<MessageListPageState>? key,
int? accountId,
BuildContext? context,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: key is for the widget, so put it next to the other widget arguments

Suggested change
GlobalKey<MessageListPageState>? key,
int? accountId,
BuildContext? context,
int? accountId,
BuildContext? context,
GlobalKey<MessageListPageState>? key,

Comment on lines 116 to 119
for (final subscription in store.subscriptions.values) {
if (widget.hideChannelsIfUserCantPost) {
if (!store.hasPostingPermission(inChannel: subscription,
user: store.selfUser, byDate: DateTime.now())) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanest to use a single timestamp (a single byDate value) through the whole list.

Comment on lines 118 to 119
if (!store.hasPostingPermission(inChannel: subscription,
user: store.selfUser, byDate: DateTime.now())) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: indent arguments

Suggested change
if (!store.hasPostingPermission(inChannel: subscription,
user: store.selfUser, byDate: DateTime.now())) {
if (!store.hasPostingPermission(inChannel: subscription,
user: store.selfUser, byDate: DateTime.now())) {

Comment on lines 136 to 141
if (widget.hideDmsIfUserCantPost) {
final hasDeactivatedUser =
narrow.otherRecipientIds.any(
(id) => !(store.getUser(id)?.isActive ?? true));
if (hasDeactivatedUser) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic doesn't implement all the nuances that the name suggests it would.

That's fine for now, but it should get a TODO comment — pointing to #791.


void _handleDmSelectForNewDms(DmNarrow narrow) {
if (widget.onDmSelect case final onDmSelect?) {
// Pop the new DMs action sheet.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
// Pop the new DMs action sheet.
// Pop the new-DMs action sheet.

Otherwise it reads like it's a "DMs action sheet" which is new.

@@ -65,6 +87,12 @@ class _SubscriptionListPageBodyState extends State<SubscriptionListPageBody> wit
});
}

void _handleChannelSelect(ChannelNarrow narrow) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this name risks confusion, because this method is only sometimes the way that this widget handles selecting a channel.

Good alternatives would be:

  • name it with "default", as in _defaultHandleChannelSelect
  • or move the fallback logic inside this method (like _RecentDmConversationsPageBodyState does) so that the method's role matches its current name

/// Callback to invoke when the user selects a channel from the list.
///
/// If null, the default behavior is to navigate to the channel feed.
final OnChannelSelectCallback? onChannelSelect;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

subscription_list [nfc]: Allow `onChannelSelect` callback, notifying the selected channel

To "notify the channel" would mean telling the channel something — the channel would be the listener, the thing that's learning information. But that's not what this does.

I think that whole clause ("notifying the selected channel") can be deleted; once "notifying" is replaced with an accurate verb, it doesn't really convey any information that's not there in the name onChannelSelect.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Similarly for a later commit.)

/// If there is an error while uploading a file, then an error dialog is
/// shown mentioning the corresponding file name.
Future<void> uploadFiles({
required Iterable<FileToUpload> files,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put these two commits next to each other in the branch:

8a3bd63 compose [nfc]: Export and rename _File to FileToUpload
fb4d254 compose [nfc]: Expose uploadFiles on ComposeBoxState

because they're closely related — the latter is basically the motivation for the former. (At present they're separated by a half-dozen other commits which these are less related to than they are to each other.)

Comment on lines 2048 to 2051
await _uploadFiles(
context: context,
contentController: controller.content,
contentFocusNode: controller.contentFocusNode,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this method really belongs on ComposeBoxController, rather than ComposeBoxState. The state it's manipulating is all on the controller, not on this overall state object.

(And the caller which has a ComposeBoxState can already easily get a ComposeBoxController, through the controller getter there.)

One reason that would be good is that it would mean the other call site of _uploadFiles (in _AttachUploadsButton._handlePress) could call the very same method. It makes me a bit nervous to have this uploadFiles method on ComposeBoxState be doing not quite the same thing as we do when the user uploads files through one of the compose buttons.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll send a separate PR for updating _AttachUploadsButton._handlePress (stacked on top of this commit).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sent #1801 for this.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and here's a review from reading the remaining, main commit.

Generally this all looks good — mostly nits, and just a couple of more substantive comments here and above.


class AndroidIntentSendEvent extends AndroidIntentEvent {
const AndroidIntentSendEvent({
required this.action, // 'android.intent.action.SEND' or 'android.intent.action.SEND_MULTIPLE'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for a comment explaining a field, put it on the field declaration, not the constructor parameter


assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}'));
assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}'));
assert(debugLog('intentSendEvent.extraStream: [${intentSendEvent.extraStream?.join(',')}]'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These objects are potentially carrying a lot of bytes, right? Seems like more than we'd want to log, even in debug.

Oh but it looks like there's no toString override on these objects. So it won't print all the contents… but it won't really print anything useful, either, about the details of any of these.

Seems like the interesting information this prints, then, is purely the number of these items (and the fact of whether the list is present or null). Let's make that explicit, then: print the list's length, instead of .join(…).

assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}'));
assert(debugLog('intentSendEvent.extraStream: [${intentSendEvent.extraStream?.join(',')}]'));

NavigatorState navigator = await ZulipApp.navigator;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
NavigatorState navigator = await ZulipApp.navigator;
final navigator = await ZulipApp.navigator;

It doesn't get mutated, right?


// TODO(#524) choose initial account as last one used
// TODO(#1779) allow selecting account, if there are multiple
final initialAccountId = globalStore.accounts.firstOrNull?.id;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why "initial"? Seems like this is simply the account ID that will get used here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump — accountId seems like the better name for this

("initial" would fit if we were starting with this account and then might switch to another one later)

Comment on lines 83 to 90
mimeType ??= lookupMimeType(
// Seems like the path shouldn't be required; we still want to look for
// matches on `headerBytes`. Thankfully we can still do that, by calling
// lookupMimeType with the empty string as the path. That's a value that
// doesn't map to any particular type, so the path will be effectively
// ignored, as desired. Upstream comment:
// https://github.com/dart-lang/mime/issues/11#issuecomment-2246824452
'',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do have a path, though: sharedFile.name. (The only part of the path that lookupMimeType is going to look at anyway is the extension, so it's fine that that's a basename and not a full path.)

Oh but that name might be something we made up as a fallback, hmm:

  } ?: ("unknown." + (mimeType?.split('/')?.last() ?: "bin"))

in which case we want to let lookupMimeType do its thing with headerBytes instead of using the name.

Well:

  • Let's move that unknown.foo fallback logic into this Dart code, so that we can better control its interaction with this other logic.
  • Then we can apply that fallback after we do this lookupMimeType call.
  • As a further bonus: that means we get to use the result of lookupMimeType in computing that fallback name, so we have a better shot at giving the name a more informative extension than .bin.

Let's do those changes as a separate commit (or couple of commits) on top, though. That way it remains easy to merge this version if we're still tweaking that logic. And this version is quite coherent already, in that it corresponds more directly to what's in the legacy app.

unawaited(navigator.push(
SharePage.buildRoute(
accountId: initialAccountId,
sharedFiles: intentSendEvent.extraStream?.map((sharedFile) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's move this whole .map out to a local variable, before the navigator.push call — that way the navigator.push and the description of the route stay compact

}

void _handleNarrowSelect(BuildContext context, Narrow narrow) {
final messageListPageStateKey = GlobalKey<MessageListPageState>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: name the key after the widget, not the widget's state

Suggested change
final messageListPageStateKey = GlobalKey<MessageListPageState>();
final messageListPageKey = GlobalKey<MessageListPageState>();

Comment on lines 136 to 143
// Wait for the message list page to accommodate in the widget tree from the route.
SchedulerBinding.instance.addPostFrameCallback((_) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what "accommodate" means in this sentence.

Does this cover what you mean?

Suggested change
// Wait for the message list page to accommodate in the widget tree from the route.
SchedulerBinding.instance.addPostFrameCallback((_) async {
// Wait for the message list page to appear in the widget tree.
SchedulerBinding.instance.addPostFrameCallback((_) async {

Comment on lines 154 to 161
// If there are any shared files, add a separator new line.
if (sharedFiles != null) text += '\n';

// Populate the content input with this text.
final contentController = composeBoxController.content;
contentController.value =
contentController.value
.replaced(contentController.insertionIndex(), text);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of insertionIndex feels like it's deep into the details of how the controller works, so that this logic probably belongs in a method on ComposeContentController.

There's already an insertPadded method on the controller, which has a lot in common with what this is trying to do. How about this?

Suggested change
// If there are any shared files, add a separator new line.
if (sharedFiles != null) text += '\n';
// Populate the content input with this text.
final contentController = composeBoxController.content;
contentController.value =
contentController.value
.replaced(contentController.insertionIndex(), text);
composeBoxController.content.insertPadded(text);

Is there an aspect of behavior you'd want to be different from what that would do?

Comment on lines 995 to 1023
"@channelsPageTitle": {
"description": "Title for the page with a list of subscribed channels."
},
"sharePageTitle": "Share",
"@sharePageTitle": {
"description": "Title for the page about sharing content received from other apps."
},
"channelsEmptyPlaceholder": "You are not subscribed to any channels yet.",
"@channelsEmptyPlaceholder": {
"description": "Centered text on the 'Channels' page saying that there is no content to show."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is separating two items that are more closely related to each other than either is to this one

Moving this to just after channelsEmptyPlaceholder would be fine.

@gnprice
Copy link
Member

gnprice commented Aug 12, 2025

(I've also tried this PR out in some manual testing, on my Pixel 8 running Android 15, and all seems well.)

This will be used soon to access `MessageListPageState` after routing
to `MessageListPage`.
… action sheet

This will be used soon to avoid unintended flows when sharing content
received from other apps.
…an't post

This will be used soon to avoid showing conversations from the
channel list where the current user can't post, specifically when
they don't have the permission.
This will be used soon to provide specific behaviour when selecting
a channel, where if specified it will replace the default behaviour
of routing to the message list page of the selected channel narrow.
This will be used soon to avoid showing conversations from the
recent DMs list where the current user can't post, specifically
when a conversation has one or more deactivated user.
This will be used soon to provide a specific behaviour when
selecting a DM, where if specified it will replace the default
behaviour of routing to the message list page of the selected DM
narrow.
@rajveermalviya
Copy link
Member Author

Thanks for the review @gnprice! Pushed an update, PTAL.

@rajveermalviya rajveermalviya requested a review from gnprice August 13, 2025 05:38
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision! Just a few nits below (and I see you have a follow-up planned at #1774 (comment), and #1787 for writing a few tests).

///
/// If there is an error while uploading a file, then an error dialog is
/// shown mentioning the corresponding file name.
Future<void> uploadFiles({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

compose [nfc]: Expose `uploadFiles` on `ComposeBoxState`

it's on ComposeBoxController

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While here I made a few other edits to the commit message:

-    compose [nfc]: Expose `uploadFiles` on `ComposeBoxState`
+    compose [nfc]: Expose an uploadFiles method on ComposeBoxController
 
-    This will be used soon to allow uploading files directly
-    via using `MessageListPageState`.
+    This will be used soon to allow uploading files from outside
+    this source file, via using `MessageListPageState`.


assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}'));
assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}'));
assert(debugLog('intentSendEvent.extraStream?.length: [${intentSendEvent.extraStream?.length}]'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the brackets seem puzzling


// TODO(#524) choose initial account as last one used
// TODO(#1779) allow selecting account, if there are multiple
final initialAccountId = globalStore.accounts.firstOrNull?.id;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump — accountId seems like the better name for this

("initial" would fit if we were starting with this account and then might switch to another one later)

sharedFile.bytes.take(defaultMagicNumbersMaxLength)));

final filename =
sharedFile.name ?? 'unknown.${mimeType?.split('/').lastOrNull ?? 'bin'}';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: split can't return an empty list

Suggested change
sharedFile.name ?? 'unknown.${mimeType?.split('/').lastOrNull ?? 'bin'}';
sharedFile.name ?? 'unknown.${mimeType?.split('/').last ?? 'bin'}';

@gnprice
Copy link
Member

gnprice commented Aug 13, 2025

In fact I'll fix those nits and then merge — that way I can make a release tonight completely from main 🎉

This will be used soon to allow uploading files from outside
this source file, via using `MessageListPageState`.
…ion list page

This reverts part of 742320c for SubscriptionListPageBody,
as this widget is planned to be used outside the context of home
page, specifically for the upcoming share page.
This reverts part of 742320c for RecentDmConversationsPageBody,
and also handles bottom insets for the new DMs button.

As this widget is planned to be used outside the context of home
page, specifically for the upcoming share page.
Enables the app to receive arbitrary content from other apps via
advertising Android Intent filters in AndroidManifest. It allows
the OS to list our app in the platform share sheet.

Adds handlers for the two Intent actions, namely SEND and
SEND_MULTIPLE. Handling all three possible combinations:

  - Receiving only a text
  - Receiving only a file (or multiple files in case of SEND_MULTIPLE)
  - Receiving both the file (or multiple) and the accompanying
    text.

The Android side Kotlin implementation is adapted from the legacy
app's implementation, with only difference being that the legacy app
didn't handle the 3rd case mentioned above, see:

  https://github.com/zulip/zulip-mobile/blob/eb8505c4a/android/app/src/main/java/com/zulipmobile/sharing/SharingHelper.kt

To allow sending Android Intent events from Kotlin to Dart,
Pigeon's EventChannelApi is used. For which the registration
happens in `MainActivity.configureFlutterEngine`, this bit of code
was adapted from Pigeon's Android example, see:

  https://github.com/flutter/packages/blob/b2aef15c1/packages/pigeon/example/app/android/app/src/main/kotlin/dev/flutter/pigeon_example_app/MainActivity.kt#L109-L121
Use the received file's name when trying to guess the mimetype in
addition to checking for magic header bytes, using `lookupMimeType`.
@gnprice gnprice force-pushed the pr-share-to-zulip branch from 9bcc247 to 529e037 Compare August 13, 2025 07:14
@gnprice gnprice merged commit 529e037 into zulip:main Aug 13, 2025
1 check passed
@rajveermalviya rajveermalviya deleted the pr-share-to-zulip branch August 13, 2025 07:19
gnprice pushed a commit to rajveermalviya/zulip-flutter that referenced this pull request Aug 14, 2025
…Button

In e4deccb (zulip#1774) ComposeBoxController started exposing an
uploadFiles method, which is now used by the Share-to-Zulip
implementation.  So use that for _AttachUploadsButton too, so as to
use the same implementation between Share-to-Zulip and compose box
upload buttons.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration maintainer review PR ready for review by Zulip maintainers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Android] Share to Zulip from other apps
4 participants